/**
 * @author Kuitos
 * @since 2019-10-21
 */
import { execScripts } from 'import-html-entry';
import { isFunction, noop } from 'lodash';
import { checkActivityFunctions } from 'single-spa';
import { frameworkConfiguration } from '../../apis';
import { Freer } from '../../interfaces';
import { getTargetValue, setProxyPropertyGetter } from '../common';
import * as css from './css';

const styledComponentSymbol = 'Symbol(styled-component-qiankun)';
const attachProxySymbol = 'Symbol(attach-proxy-qiankun)';

declare global {
  interface HTMLStyleElement {
    // eslint-disable-next-line no-undef
    [styledComponentSymbol]?: CSSRuleList;
  }
}

const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
const rawHeadRemoveChild = HTMLHeadElement.prototype.removeChild;
const rawBodyAppendChild = HTMLBodyElement.prototype.appendChild;
const rawBodyRemoveChild = HTMLBodyElement.prototype.removeChild;
const rawHeadInsertBefore = HTMLHeadElement.prototype.insertBefore;

const rawAppendChild = HTMLElement.prototype.appendChild;
const rawRemoveChild = HTMLElement.prototype.removeChild;
const rawInsertBefore = HTMLElement.prototype.insertBefore;

const SCRIPT_TAG_NAME = 'SCRIPT';
const LINK_TAG_NAME = 'LINK';
const STYLE_TAG_NAME = 'STYLE';

/**
 * Check if a style element is a styled-component liked.
 * A styled-components liked element is which not have textContext but keep the rules in its styleSheet.cssRules.
 * Such as the style element generated by styled-components and emotion.
 * @param element
 */
function isStyledComponentsLike(element: HTMLStyleElement) {
  return !element.textContent && ((element.sheet as CSSStyleSheet)?.cssRules.length || getCachedRules(element)?.length);
}

function getCachedRules(element: HTMLStyleElement) {
  return element[styledComponentSymbol];
}

function setCachedRules(element: HTMLStyleElement, cssRules: CSSRuleList) {
  Object.defineProperty(element, styledComponentSymbol, { value: cssRules, configurable: true, enumerable: false });
}

function getNewAppendChild(args: {
  appName: string;
  proxy: WindowProxy;
  singular: boolean;
  dynamicStyleSheetElements: HTMLStyleElement[];
  appWrapperGetter: CallableFunction;
  headOrBodyAppendChild: typeof HTMLElement.prototype.appendChild;
  scopedCSS: boolean;
  excludeAssetFilter?: CallableFunction;
}) {
  return function appendChild<T extends Node>(this: HTMLHeadElement | HTMLBodyElement, newChild: T) {
    const element = newChild as any;
    const { headOrBodyAppendChild } = args;
    if (element.tagName) {
      // eslint-disable-next-line prefer-const
      let { appWrapperGetter, proxy, singular, dynamicStyleSheetElements } = args;
      const { appName, scopedCSS, excludeAssetFilter } = args;

      const storedContainerInfo = element[attachProxySymbol];
      if (storedContainerInfo) {
        // eslint-disable-next-line prefer-destructuring
        singular = storedContainerInfo.singular;
        // eslint-disable-next-line prefer-destructuring
        appWrapperGetter = storedContainerInfo.appWrapperGetter;
        // eslint-disable-next-line prefer-destructuring
        dynamicStyleSheetElements = storedContainerInfo.dynamicStyleSheetElements;
        // eslint-disable-next-line prefer-destructuring
        proxy = storedContainerInfo.proxy;
      }

      const invokedByMicroApp = singular
        ? // check if the currently specified application is active
          // While we switch page from qiankun app to a normal react routing page, the normal one may load stylesheet dynamically while page rendering,
          // but the url change listener must to wait until the current call stack is flushed.
          // This scenario may cause we record the stylesheet from react routing page dynamic injection,
          // and remove them after the url change triggered and qiankun app is unmouting
          // see https://github.com/ReactTraining/history/blob/master/modules/createHashHistory.js#L222-L230
          checkActivityFunctions(window.location).some(name => name === appName)
        : // have storedContainerInfo means it invoked by a micro app in multiply mode
          !!storedContainerInfo;

      switch (element.tagName) {
        case LINK_TAG_NAME:
        case STYLE_TAG_NAME: {
          const stylesheetElement: HTMLLinkElement | HTMLStyleElement = newChild as any;
          if (!invokedByMicroApp) {
            return headOrBodyAppendChild.call(this, element) as T;
          }

          const mountDOM = appWrapperGetter();
          const { href } = stylesheetElement as HTMLLinkElement;
          if (excludeAssetFilter && href && excludeAssetFilter(href)) {
            return rawAppendChild.call(mountDOM, element) as T;
          }

          if (scopedCSS) {
            css.process(mountDOM, stylesheetElement, appName);
          }

          // eslint-disable-next-line no-shadow
          dynamicStyleSheetElements.push(stylesheetElement);
          return rawAppendChild.call(mountDOM, stylesheetElement) as T;
        }

        case SCRIPT_TAG_NAME: {
          if (!invokedByMicroApp) {
            return headOrBodyAppendChild.call(this, element) as T;
          }

          const mountDOM = appWrapperGetter();
          const { src, text } = element as HTMLScriptElement;

          // some script like jsonp maybe not support cors which should't use execScripts
          if (excludeAssetFilter && src && excludeAssetFilter(src)) {
            return rawAppendChild.call(mountDOM, element) as T;
          }

          const { fetch } = frameworkConfiguration;
          if (src) {
            execScripts(null, [src], proxy, { fetch, strictGlobal: !singular }).then(
              () => {
                // we need to invoke the onload event manually to notify the event listener that the script was completed
                // here are the two typical ways of dynamic script loading
                // 1. element.onload callback way, which webpack and loadjs used, see https://github.com/muicss/loadjs/blob/master/src/loadjs.js#L138
                // 2. addEventListener way, which toast-loader used, see https://github.com/pyrsmk/toast/blob/master/src/Toast.ts#L64
                const loadEvent = new CustomEvent('load');
                if (isFunction(element.onload)) {
                  element.onload(loadEvent);
                } else {
                  element.dispatchEvent(loadEvent);
                }
              },
              () => {
                const errorEvent = new CustomEvent('error');
                if (isFunction(element.onerror)) {
                  element.onerror(errorEvent);
                } else {
                  element.dispatchEvent(errorEvent);
                }
              },
            );

            const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
            return rawAppendChild.call(mountDOM, dynamicScriptCommentElement) as T;
          }

          execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal: !singular }).then(
            element.onload,
            element.onerror,
          );
          const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
          return rawAppendChild.call(mountDOM, dynamicInlineScriptCommentElement) as T;
        }

        default:
          break;
      }
    }

    return headOrBodyAppendChild.call(this, element) as T;
  };
}

function getNewRemoveChild(args: {
  appWrapperGetter: CallableFunction;
  headOrBodyRemoveChild: typeof HTMLElement.prototype.removeChild;
}) {
  return function removeChild<T extends Node>(this: HTMLHeadElement | HTMLBodyElement, child: T) {
    const { headOrBodyRemoveChild } = args;
    let { appWrapperGetter } = args;

    const storedContainerInfo = (child as any)[attachProxySymbol];
    if (storedContainerInfo) {
      // eslint-disable-next-line prefer-destructuring
      appWrapperGetter = storedContainerInfo.appWrapperGetter;
    }

    try {
      // container may had been removed while app unmounting if the removeChild action was async
      const container = appWrapperGetter();
      if (container.contains(child)) {
        return rawRemoveChild.call(container, child) as T;
      }
    } catch (e) {
      console.warn(e);
    }

    return headOrBodyRemoveChild.call(this, child) as T;
  };
}

function getNewInsertBefore(args: {
  appName: string;
  proxy: WindowProxy;
  singular: boolean;
  dynamicStyleSheetElements: HTMLStyleElement[];
  appWrapperGetter: CallableFunction;
  headOrBodyInsertBefore: typeof HTMLElement.prototype.insertBefore;
  scopedCSS: boolean;
}) {
  return function insertBefore<T extends Node>(this: HTMLHeadElement, newChild: T, refChild: Node | null): T {
    const element = newChild as any;
    const { headOrBodyInsertBefore } = args;
    if (element.tagName) {
      // eslint-disable-next-line prefer-const
      let { appName, appWrapperGetter, proxy, singular, dynamicStyleSheetElements, scopedCSS } = args;

      const storedContainerInfo = element[attachProxySymbol];
      if (storedContainerInfo) {
        // eslint-disable-next-line prefer-destructuring
        singular = storedContainerInfo.singular;
        // eslint-disable-next-line prefer-destructuring
        appWrapperGetter = storedContainerInfo.appWrapperGetter;
        // eslint-disable-next-line prefer-destructuring
        dynamicStyleSheetElements = storedContainerInfo.dynamicStyleSheetElements;
      }

      const invokedByMicroApp = singular
        ? checkActivityFunctions(window.location).some(name => name === appName)
        : !!storedContainerInfo;

      switch (element.tagName) {
        case LINK_TAG_NAME:
        case STYLE_TAG_NAME: {
          const stylesheetElement: HTMLLinkElement | HTMLStyleElement = newChild as any;

          if (!invokedByMicroApp) {
            return headOrBodyInsertBefore.call(this, element, refChild) as T;
          }

          const mountDOM = appWrapperGetter();

          if (scopedCSS) {
            css.process(mountDOM, stylesheetElement, appName);
          }

          dynamicStyleSheetElements.push(stylesheetElement);
          const referenceNode = mountDOM.contains(refChild) ? refChild : null;
          return rawInsertBefore.call(mountDOM, stylesheetElement, referenceNode) as T;
        }

        case SCRIPT_TAG_NAME: {
          if (!invokedByMicroApp) {
            return headOrBodyInsertBefore.call(this, element, refChild) as T;
          }

          const { src, text } = element as HTMLScriptElement;

          const { fetch } = frameworkConfiguration;

          const mountDOM = appWrapperGetter();
          const referenceNode = mountDOM.contains(refChild) ? refChild : null;

          if (src) {
            execScripts(null, [src], proxy, { fetch, strictGlobal: !singular }).then(
              () => {
                // we need to invoke the onload event manually to notify the event listener that the script was completed
                // here are the two typical ways of dynamic script loading
                // 1. element.onload callback way, which webpack and loadjs used, see https://github.com/muicss/loadjs/blob/master/src/loadjs.js#L138
                // 2. addEventListener way, which toast-loader used, see https://github.com/pyrsmk/toast/blob/master/src/Toast.ts#L64
                const loadEvent = new CustomEvent('load');
                if (isFunction(element.onload)) {
                  element.onload(loadEvent);
                } else {
                  element.dispatchEvent(loadEvent);
                }
              },
              () => {
                const errorEvent = new CustomEvent('error');
                if (isFunction(element.onerror)) {
                  element.onerror(errorEvent);
                } else {
                  element.dispatchEvent(errorEvent);
                }
              },
            );

            const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
            return rawInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode) as T;
          }

          execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal: !singular }).then(
            element.onload,
            element.onerror,
          );
          const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
          return rawInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode) as T;
        }
        default:
          break;
      }
    }

    return headOrBodyInsertBefore.call(this, element, refChild) as T;
  };
}

let bootstrappingPatchCount = 0;
let mountingPatchCount = 0;

/**
 * Just hijack dynamic head append, that could avoid accidentally hijacking the insertion of elements except in head.
 * Such a case: ReactDOM.createPortal(<style>.test{color:blue}</style>, container),
 * this could made we append the style element into app wrapper but it will cause an error while the react portal unmounting, as ReactDOM could not find the style in body children list.
 * @param appName
 * @param appWrapperGetter
 * @param proxy
 * @param mounting
 * @param singular
 */
export default function patch(
  appName: string,
  appWrapperGetter: () => HTMLElement | ShadowRoot,
  proxy: Window,
  mounting = true,
  singular = true,
  scopedCSS = false,
  excludeAssetFilter?: CallableFunction,
): Freer {
  let dynamicStyleSheetElements: Array<HTMLLinkElement | HTMLStyleElement> = [];
  let deleteProxyPropertyGetter: Function = noop;

  if (!singular) {
    deleteProxyPropertyGetter = setProxyPropertyGetter(proxy, 'document', () => {
      return new Proxy(document, {
        get(target: Document, property: PropertyKey): any {
          if (property === 'createElement') {
            return function createElement(tagName: string, options?: any) {
              const element = document.createElement(tagName, options);

              if (tagName?.toLowerCase() === 'style' || tagName?.toLowerCase() === 'script') {
                Object.defineProperty(element, attachProxySymbol, {
                  value: { appName, proxy, appWrapperGetter, dynamicStyleSheetElements },
                  enumerable: false,
                });
              }

              return element;
            };
          }

          return getTargetValue(document, (<any>target)[property]);
        },

        set(target: Document, p: PropertyKey, value: any): boolean {
          // eslint-disable-next-line no-param-reassign
          (<any>target)[p] = value;
          return true;
        },
      });
    });
  }

  // Just overwrite it while it have not been overwrite
  if (
    HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
    HTMLBodyElement.prototype.appendChild === rawBodyAppendChild
  ) {
    HTMLHeadElement.prototype.appendChild = getNewAppendChild({
      headOrBodyAppendChild: rawHeadAppendChild,
      appName,
      appWrapperGetter,
      proxy,
      singular,
      dynamicStyleSheetElements,
      scopedCSS,
      excludeAssetFilter,
    });
    HTMLBodyElement.prototype.appendChild = getNewAppendChild({
      headOrBodyAppendChild: rawBodyAppendChild,
      appName,
      appWrapperGetter,
      proxy,
      singular,
      dynamicStyleSheetElements,
      scopedCSS,
      excludeAssetFilter,
    });
  }

  // Just overwrite it while it have not been overwrite
  if (
    HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&
    HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild
  ) {
    HTMLHeadElement.prototype.removeChild = getNewRemoveChild({
      appWrapperGetter,
      headOrBodyRemoveChild: rawHeadRemoveChild,
    });
    HTMLBodyElement.prototype.removeChild = getNewRemoveChild({
      appWrapperGetter,
      headOrBodyRemoveChild: rawBodyRemoveChild,
    });
  }

  // `emotion` a css-in-js library insert a style tag use insertBefore, so we also rewrite it like appendChild
  // see https://github.com/umijs/qiankun/issues/420
  if (HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore) {
    HTMLHeadElement.prototype.insertBefore = getNewInsertBefore({
      appName,
      appWrapperGetter,
      proxy,
      singular,
      dynamicStyleSheetElements,
      headOrBodyInsertBefore: rawHeadInsertBefore,
      scopedCSS,
    });
  }

  if (!mounting) bootstrappingPatchCount++;
  if (mounting) mountingPatchCount++;

  return function free() {
    // bootstrap patch just called once but its freer will be called multiple times
    if (!mounting && bootstrappingPatchCount !== 0) bootstrappingPatchCount--;
    if (mounting) mountingPatchCount--;

    // release the overwrite prototype after all the micro apps unmounted
    if (mountingPatchCount === 0 && bootstrappingPatchCount === 0) {
      HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
      HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
      HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
      HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;

      HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
    }

    dynamicStyleSheetElements.forEach(stylesheetElement => {
      /*
         With a styled-components generated style element, we need to record its cssRules for restore next re-mounting time.
         We're doing this because the sheet of style element is going to be cleaned automatically by browser after the style element dom removed from document.
         see https://www.w3.org/TR/cssom-1/#associated-css-style-sheet
         */
      if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
        if (stylesheetElement.sheet) {
          // record the original css rules of the style element for restore
          setCachedRules(stylesheetElement, (stylesheetElement.sheet as CSSStyleSheet).cssRules);
        }
      }

      // As now the sub app content all wrapped with a special id container,
      // the dynamic style sheet would be removed automatically while unmoutting
    });

    deleteProxyPropertyGetter();

    return function rebuild() {
      dynamicStyleSheetElements.forEach(stylesheetElement => {
        // re-append the dynamic stylesheet to sub-app container
        // Using document.head.appendChild ensures that appendChild calls
        // can also directly use the HTMLHeadElement.prototype.appendChild method which is overwritten at mounting phase
        document.head.appendChild.call(appWrapperGetter(), stylesheetElement);

        /*
        get the stored css rules from styled-components generated element, and the re-insert rules for them.
        note that we must do this after style element had been added to document, which stylesheet would be associated to the document automatically.
        check the spec https://www.w3.org/TR/cssom-1/#associated-css-style-sheet
         */
        if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
          const cssRules = getCachedRules(stylesheetElement);
          if (cssRules) {
            // eslint-disable-next-line no-plusplus
            for (let i = 0; i < cssRules.length; i++) {
              const cssRule = cssRules[i];
              (stylesheetElement.sheet as CSSStyleSheet).insertRule(cssRule.cssText);
            }
          }
        }
      });

      // As the hijacker will be invoked every mounting phase, we could release the cache for gc after rebuilding
      if (mounting) {
        dynamicStyleSheetElements = [];
      }
    };
  };
}
